Repository navigation
Conversation
99e04e7 to
05ad8b1
Compare
- add ComboType to GroupOrderManager and the OneCancelsTheOtherOrder api - gate live order groups behind BrokerageModel.SupportsGroupExecution - simulate group fill and sibling cancel in the backtesting brokerage - count one leg per group in buying power and open order aggregations - carry the group manager through the order factory and json round trip
- end the group pass on any fill, partial as well as complete - take the executed quantity off every other open leg instead of only canceling on a complete fill, and cancel a leg left with nothing to execute - add a regression algorithm that fails without the fix - assert the sibling reduction in the backtesting brokerage tests
- add a second group that buys 100 and is won by its stop market leg - sell 200 in the first group so holdings reverse through zero - open the position on its own bar and drop the price rounding the transaction handler already does - find the legs by order type instead of by list index
- reject a group that is not exactly 2 legs, us equity, limit plus stop market, one side, day or gtc tif - add the five brokerage messages for those rejections - cover the valid group and every rejection reason, and make the group execution gate test able to fail
- move the enum to its own file Common/Orders/GroupExecutionType.cs - rename GroupOrderManager.ComboType to ExecutionType, json name to executionType
- lean already rounds order prices before sending them - mirror the change in the python twin of the cancel algorithm
- PreOrderChecksImpl already answers ZeroQuantity, so the group comes back as one invalid ticket - assert the invalid ticket instead of the exception
- drop the per group skip so every open order gets its own cancel, like CancelOpenOrders - a leg its siblings already canceled answers with an error response instead of throwing
- remove SupportsGroupExecution from IBrokerageModel, DefaultBrokerageModel, alpaca, ib and the python wrapper - remove the live mode check from the transaction handler and every test that covered it - restore PythonWrapper as it was: no default bodied interface method is left to skip
- places a market entry and a 2 leg oco exit, logs every order event - manual live/paper testing aid, it does not implement IRegressionAlgorithmDefinition
- ProcessOneCancelsTheOtherGroup returns true when the group must be evaluated again, no ref parameter - shorten the comments to the reason behind the code
- CompositeLogHandler and BrokerageTests go back to master, they are not part of the order group work - restore the group manager comments to one line each in Order and OrderJsonConverter - place the cash buying power oco test through OneCancelsTheOtherOrder instead of hand built requests
9df6d96 to
cdfd68c
Compare
- one line each in CashBuyingPowerModel, SecurityPortfolioManager and BrokerageTransactionHandler
|
Design notes behind this PR, part 1 of 2. ADR 0001 is the research step: it checks every ADR 0001: Bracket order typeADR 0001: Bracket order typeStatusProposed - 2026-07-09 PurposeImplement a new ContextA bracket order is an entry (parent) order with two attached exit orders: a take-profit limit and a stop-loss. The two exits are OCO (one cancels the other). Lean users ask for this feature in live trading, but each brokerage API supports it differently: some have a native bracket/OTOCO order type, some only allow one attached exit, some only support standalone TP/SL orders, and some do not support it at all. Before designing the Lean-side API, we researched every brokerage that has a Classification used in the table:
Broker API support matrix
OCO / OTO primitive support matrixA bracket is not a broker primitive on its own; it decomposes into two generic mechanisms that most brokers already expose separately or in combination:
This table records, per brokerage, whether OCO and OTO/conditional are available standalone (their own order class) and whether a combined OTOCO/bracket exists. Legend: yes / no / partial (only in a limited or attached form) / — (unknown / not publicly documented).
Takeaway: the composable OCO + OTO model is the norm, not the exception. Seven brokers expose OCO and OTO/OSO/TRIGGER as distinct order classes with OTOCO/bracket as the explicit combination (InteractiveBrokers, Alpaca, Tradier, Binance spot, WeBull, TradeStation, CharlesSchwab). Standalone OCO is broadly available beyond those (Bitfinex, Zerodha GTT, TradingTechnologies, Rithmic, Tastytrade). Only a minority are bracket-/attach-only (Coinbase, OANDA, Bybit) or single-conditional-only (dYdX, Kraken spot). This supports implementing OCO and conditional (OTO) as independent Lean primitives, with bracket as a thin OTOCO composition on top. Reference implementationsEarlier PRs that added a new order type to Lean. They show every place a new order type must touch:
|
|
Part 2 of 2. ADR 0002 is the decision record for the OCO order group in this PR: the API shape, the engine rule for partial fills, buying power, what each brokerage allows, the live mappings, and the known gaps. ADR 0002: One-Cancels-the-Other (OCO) order groupADR 0002: One-Cancels-the-Other (OCO) order groupStatusAccepted - 2026-08-20 (proposed 2026-07-16). What exists today:
Companion documents: PurposeADR 0001 research showed that a bracket order is not one thing. It is two smaller, generic pieces:
The team decision is to build the two pieces separately and then build the bracket as a thin layer on top: One extra goal shaped every choice: the OCO code must be built from pieces that OTO and the bracket can What is an OCO order groupThe simple picture:
A story example. You own 100 AAPL bought at $200. You want to take profit at $220, but you also want
If the price reaches $220 first, the limit order fills and the stop order is canceled. If the price falls to Important difference from the next PR: in an OCO group no order waits for another order to fill. The What brokers allowHow wide a group each brokerage accepts. "Legs" is how many orders fit in one group, and the last three
Three shapes come out of this. InteractiveBrokers, TradeStation and WeBull let the legs differ in every way: order type, symbol and side. CharlesSchwab is in between: its OCO is a wrapper around a list of complete orders, each with its own Alpaca, Binance spot, Tradier, Tastytrade and Zerodha are a fixed take profit + stop loss pair on one Verified beyond the docs: a 5-leg group placed through IB Gateway (see "Live mapping: InteractiveBrokers"). The mixed rows for InteractiveBrokers and WeBull come from their desktop platforms, not from an API call. The The CharlesSchwab row comes from the Trader API documentation saved locally (October 2024 snapshot), since the One useful thing the APIs show: an OCO group is really flat. Every leg is equal; nobody is the boss. The What this means for the Lean API: we are narrower than every brokerage in the table. Which brokerages have a native OCO order class at all, with the OTO and bracket columns beside it, is in the Scope for v1
Whether the public API should stay this narrow is still open — see Algorithm APIOne new method on public List<OrderTicket> OneCancelsTheOtherOrder(Symbol symbol, decimal quantity, decimal limitPrice, decimal stopPrice,
bool asynchronous = false, string tag = "", IOrderProperties orderProperties = null)The method builds the two legs itself — a Decision: the input is scalar parameters, not a list of orders. A
Starting example: public override void OnData(Slice slice)
{
if (!Portfolio.Invested)
{
MarketOrder(_symbol, 100);
// close the position either with profit at 220 or with protection at 190
var tickets = OneCancelsTheOtherOrder(_symbol, -100, limitPrice: 220m, stopPrice: 190m);
}
}The method has no validation of its own. A zero quantity is already rejected by All-or-nothing submit: the pre-order checks run for every leg before any leg is submitted (same pattern as Internally the public method is a thin wrapper over one shared private submitter that the next PRs reuse: // one submitter for every group type that is not the existing ratio-based combo; OCO is the first caller
private List<OrderTicket> SubmitGroupOrder(GroupExecutionType groupExecutionType, List<Order> orders,
bool asynchronous, string tag, IOrderProperties orderProperties)
// ADR 0003 (conditional):
public List<OrderTicket> OneTriggersTheOtherOrder(Order trigger, List<Order> children, ...)
// final PR (bracket sugar): builds entry + take profit + stop loss and calls SubmitGroupOrder(GroupExecutionType.Bracket, ...)
public List<OrderTicket> BracketOrder(Symbol symbol, decimal quantity,
decimal takeProfitLimitPrice, decimal stopLossPrice, ...)
DesignThe execution type flagWe reuse public enum GroupExecutionType
{
/// <summary>All legs are placed and filled together as one unit (today's combo behavior) (0)</summary>
Combo = 0,
/// <summary>One leg fills -> every other leg in the group is canceled (1)</summary>
OneCancelsTheOther = 1
}The enum is designed to grow. The next PRs append (never renumber — enum ints are persisted):
New serialized property on [JsonProperty(PropertyName = "executionType", DefaultValueHandling = DefaultValueHandling.Ignore)]
public GroupExecutionType ExecutionType { get; set; }
Why the flag is needed: combo code assumes all legs share one fate (all-or-nothing fills, group expiry, group The name was Group values for an OCO group: How the orders flow through the engine
Buying powerBuying power is a shared building block with a per-type rule, and it lives where both backtest and live
Update path and existing "manager means combo" assumptions
A grep audit of every Live safety: no brokerage gate in v1This is the one dangerous spot of the design. OCO legs are plain Limit/StopMarket orders, so every brokerage v1 ships without a generic gate for this. What protects a live algorithm today:
Remark:
|
| Place | What it did |
|---|---|
IBrokerageModel |
declared the member with a default body, so existing models kept working |
DefaultBrokerageModel |
the implementation: only Combo passes |
AlpacaBrokerageModel, InteractiveBrokersBrokerageModel |
overrode it to also answer true for OneCancelsTheOther |
BrokerageModelPythonWrapper |
forwarded to supports_group_execution, falling back to the default when a Python model did not define it |
BrokerageTransactionHandler |
checked it once per group, live only (!_brokerageIsBacktesting), and invalidated the whole group with BrokerageModelRefusedToSubmitOrder |
It also needed PythonWrapper.ValidateImplementationOf to stop reporting default-bodied interface methods as
missing, so old Python brokerage models kept validating.
Why it was removed. Adding a member to the public IBrokerageModel interface is a heavy, hard-to-take-back
step, and it is spread over five files plus a Python path. It bought nothing for the only live mapping that
exists: Alpaca already refuses what it cannot place, per leg, in CanSubmitOrder. So v1 pays no interface
cost for a case no brokerage needs yet.
When to add it back. When a second brokerage maps order groups, or when OTO/bracket land and more than
one plugin has to answer "can you do this kind of group". At that point the shape above is the starting
point, with two things to decide again:
- Does it belong on
IBrokerageModelat all, or doesCanSubmitOrder(which already sees the leg and its
GroupOrderManager) carry the rule per brokerage, like Alpaca does today? - If it does go on the interface, keep the default body, and remember the Python wrapper and
ValidateImplementationOfhave to move with it.
Until then, treat "this brokerage understands order groups" as knowledge that lives in each plugin.
Lifecycle
State walk-through for a 3-leg group (A = main, B, C — these are the engine rules; the user API places the
2-leg case):
| # | Step | A | B | C | Events |
|---|---|---|---|---|---|
| 0 | The group is placed, one ticket per leg | New | New | New | none (requests buffered until the last leg) |
| 1 | Group validated and placed | Submitted | Submitted | Submitted | 3x Submitted |
| 2 | Leg B fully fills | Canceled | Filled | Canceled | one batch: Filled(B), Canceled(A, "OCO"), Canceled(C, "OCO") |
| 3 | User cancels any one leg | Canceled | Canceled | Canceled | 3x Canceled (cancel one = cancel all) |
| 4 | User updates a leg price | unchanged | unchanged | unchanged | UpdateSubmitted on that leg only |
| 5 | Time in force expires | Canceled | Canceled | Canceled | 3x Canceled ("expired") |
| 6 | Submit-time failure on any leg | Invalid | Invalid | Invalid | 3x Invalid via InvalidateOrders |
| 7 | Leg B partially fills 30 of 100 | Submitted, quantity 70 | PartiallyFilled | Submitted, quantity 70 | PartiallyFilled(B); A and C reduced by 30, each with an UpdateSubmitted event |
The rules, short version:
- One group, one size. The group can never execute more than its own quantity.
- After any execution of X on one leg, every other open leg is reduced by X. A leg reduced to zero is canceled
in the same event batch. - Only one leg can execute per bar. The group stops as soon as a leg produces any fill, full or partial.
- Cancel one leg = cancel the whole group.
- Update one leg = only that leg changes.
Partial fills: reduce the siblings, do not cancel them
A leg does not always fill in one piece. The rule is the one InteractiveBrokers uses: take the filled
quantity off the other legs.
The story trade again. You hold 100 shares, take profit at $220, stop loss at $190. The stop fills only 30:
| Rule | The limit leg becomes | Result |
|---|---|---|
| Cancel siblings only on a full fill | still 100 | the group sells 130 — more than you own |
| Cancel siblings on any fill | canceled | 70 shares left with no protection and no target |
| Reduce siblings by the filled amount | 100 -> 70 | the total can never pass 100, and the rest stays protected |
The third one is the only one with no bad side, so that is the rule. It also makes the design smaller, because
"cancel" stops being a separate rule. Canceling on a full fill is just the case where the other legs are
reduced to zero. One rule replaces two:
after a leg executes X, take X off every other open leg; a leg that reaches zero is canceled.
ReduceOpenSiblings has three branches. Two of them fire in normal use:
| Branch | When | What happens |
|---|---|---|
The other leg is Filled |
it used the whole group quantity | every open sibling is canceled |
| The sibling still has quantity left | a partial fill | the sibling's quantity drops by the executed amount |
| The sibling drops to zero or below | the legs have different sizes | the sibling is canceled |
The third one cannot happen through OneCancelsTheOtherOrder, because both legs get the same quantity: a
partial fill always leaves the sibling above zero, and a full fill takes the first branch instead. It becomes
reachable only when a per-leg update changes one leg's size, which the update path now allows. It has no
test — see "Known gaps".
Details the method settles:
- A canceled leg keeps the quantity it was submitted with, so it still reports what it was asked for. It is
canceled rather than left open at zero: a zero-quantity order has no direction and no fill model fills it. - The reduce event carries
FillQuantity = 0and statusUpdateSubmitted, so the transaction handler filters
it out beforeProcessFillsandTradeBuilder— holdings math is untouched. - Reducing a leg is a plain assignment:
Order.Quantityhas aninternalsetter,QuantConnect.Brokerages
already hasInternalsVisibleTo, and the pending set holds the handler's own instance with no clone.
Backtesting and paper
Home: BacktestingBrokerage.Scan (Brokerages/Backtesting/BacktestingBrokerage.cs). After group resolution
the guard is ExecutionType != Combo, and the type dispatch is a switch with a loud default that invalidates
the group. A lazily created HashSet<int> processedGroupIds makes sure a group is evaluated once per pass and
not once per leg — a group has one entry in the pending set per leg.
Why not in FillModel: fill models are user-replaceable per security and overridable from Python. The OCO
promise ("never more than the group size") must not depend on user code. The fill models keep doing what they
do today — decide the fill price of a single leg. The group logic lives one level up.
Why not in BrokerageTransactionHandler: it is shared with real live trading, where the broker cancels the
losing legs itself. Engine-side cancel there would double-cancel.
ProcessOneCancelsTheOtherGroup returns one bool: true when the group is still open and has to be evaluated
again on a later scan. That is the only thing Scan needs back from it, so the group branch reads
stillNeedsScan |= ProcessOneCancelsTheOtherGroup(orders, securities). Every early exit answers the same
question: a group that was invalidated returns false, a group waiting on a pending cancel or on a later bar
returns true. The method is the composition of:
- Leg-type check: only Limit and StopMarket.
QCAlgorithm.OneCancelsTheOtherOrderbuilds exactly those two
legs, so a group placed through the API can hold nothing else — but a group rebuilt from JSON can carry any
type, andTryFillLegonly knows how to evaluate a plain Limit/StopMarket fill. TryOrderPreChecks— reused as is. It already cancels the whole group on time-in-force expiry, so the
ExpireGroupstep this ADR originally planned was deliberately not built.- Buying power for the group, with the
CancelPendingdefer and the try/catch that invalidates the group on
a margin-model error. - Evaluate the open legs in a fixed order — stop-type legs first, then limit legs, then by Lean order Id.
Deterministic, and pessimistic when one bar could fill two legs at once: the tie rule the bracket inherits
for its exits. TryFillLeg(order, security, securities)per leg — one leg with its security'sFillModel, its time in
force and its fee. Knows nothing about groups.- The first leg that produces any executed quantity ends the pass, full or partial ->
ReduceOpenSiblings
into the same event batch. Stopping on a partial fill too is what keeps a second leg from executing in
the same bar. Status-only events with no quantity do not reduce anything. - Emit the batch, and drop the group from the pending set only when every leg is closed.
The pending-set invariant: a closed leg stays in the pending set while any sibling is open, and leaves only
when the whole group is closed. If it is missed anywhere, TryGetGroupOrders cannot resolve the group and the
surviving leg becomes uncancelable. Three of the touch points shipped — RemoveOrders and CancelOrder both
skip already-closed legs, and the processor's tail removes the group only when all legs are closed. Two did
not: Scan's purge branch still removes any closed order on sight, and UpdateOrder still accepts anything in
the pending set. Neither is reachable while an OCO group always closes every leg in the same batch, but the
Scan one becomes a real bug the moment OTO or the bracket lands. See the review, §4.6.
Paper trading comes free: PaperBrokerage extends BacktestingBrokerage and reuses Scan.
Built to be reused by OTO and the bracket
The map of every building block, and who consumes it later. The honest claim after the code review: OTO adds
the dormancy/activation step plus a handful of trigger-aware branches; the bracket adds no new mechanism — it
composes the existing steps.
| Building block | Lives in | OCO (this PR) | OTO (ADR 0003) | Bracket (final PR) |
|---|---|---|---|---|
GroupExecutionType + GroupOrderManager.ExecutionType |
Common | shipped, value OneCancelsTheOther = 1 |
appends OneTriggersTheOther = 2 |
appends Bracket = 3 |
Serialization fixes (plain legs carry the group; executionType survives the round trip) |
Common | shipped | reused as is | reused as is |
SubmitGroupOrder shared submitter |
QCAlgorithm | shipped; OCO wrapper | new thin wrapper | new thin wrapper (the "sugar") + trigger-aware sync wait |
Order.GetOrderPrices price reader |
Common | shipped, generic | reused as is | reused as is |
| Brokerage gate for order groups | Common | not in v1, see the remark under "Live safety" | add it back when a second brokerage maps groups | reused once it exists |
Group leg buffering (BrokerageTransactionHandler + GroupOrderCacheManager) |
existing | reused as is | reused as is | reused as is |
Group buying power by execution type + sibling exclusion in CashBuyingPowerModel |
Common | shipped (rule: max leg) | new rule: trigger, then sum of children | new rule: trigger, then max exit |
GetEffectiveOpenQuantityTickets for open-order aggregations |
Common | shipped (max leg per group) | extends with dormant-children rule | reused as is |
| Cancel one leg = cancel the group + skip-closed guard | BacktestingBrokerage | shipped | reused as is | reused as is |
Update path narrowed to ExecutionType == Combo |
handler | shipped | reused as is | reused as is |
TryFillLeg single-leg evaluation |
BacktestingBrokerage | shipped | reused as is | reused as is |
ReduceOpenSiblings (the OCO step) |
BacktestingBrokerage | shipped | not used | reused on the two exits |
| Stops-first deterministic leg order | BacktestingBrokerage | shipped, but inline | reused | reused (SL before TP on a tie) |
| Emit + remove-if-closed tail and the pending-set invariant | BacktestingBrokerage | 3 of 5 touch points | needs the other 2 | reused as is |
| Dormancy + activation step (children wait for the trigger) | BacktestingBrokerage | not needed | ships in ADR 0003 | reused on the entry->exits switch |
Trigger-aware engine branches (Scan same-bar rule; submitter sync wait) |
BacktestingBrokerage + QCAlgorithm | not needed | ships in ADR 0003 | reused as is |
How the three processors read once everything exists (sketch, not code):
switch (group.ExecutionType)
{
OneCancelsTheOther: evaluate legs -> first leg with any fill ends the pass -> ReduceOpenSiblings
OneTriggersTheOther: trigger open? -> TryFillLeg(trigger); on fill -> activate children
trigger filled? -> evaluate children independently (no OCO link)
Bracket: trigger open? -> TryFillLeg(entry); on fill -> activate children
trigger filled? -> evaluate children -> first leg with any fill -> ReduceOpenSiblings
}
Reserved extension point (documented now, shipped in ADR 0003): OTO and the bracket must know which leg is
the trigger. The plan is one more serialized field on GroupOrderManager — TriggerOrderId (int, 0 = no
trigger, ignored when default) — instead of new order types for the trigger leg. ADR 0001 used new order types
(BracketMarket/BracketLimit) mainly to get live gating for free from the model whitelists; the
gate that job would have covered is gone for now, and plain legs plus TriggerOrderId still keep every leg
reusing the existing order classes end to end. This PR does not add the field — it only keeps every
design choice compatible with it. Note the trap: DeserializeGroupOrderManager rebuilds the manager field by
field, so TriggerOrderId will be dropped on restart unless a line is added there too.
Today SubmitGroupOrder takes the group's quantity and direction from orders[0] and nothing says so. For
OCO both legs share a quantity, so the convention is invisible; for OTO the first leg is the trigger, and a
wrapper that passes children first would give the group a child's quantity. Write the convention down when
ADR 0003 lands, and set TriggerOrderId explicitly instead of leaving the meaning positional.
Live mapping: Alpaca
Repo: Lean.Brokerages.Alpaca, branch feature-alpaca-oco-orders, draft PR #74. Alpaca has a native OCO order
class: one POST /v2/orders with order_class=oco. What shipped:
- Model rules, per leg (
Common/Brokerages/AlpacaBrokerageModel.csin Lean core).
CanSubmitOrderruns a per-leg check for the rules Alpaca is stricter about: exactly 2 legs, US equities only, Limit or StopMarket leg types,
every leg on the same side as the group, and a Day or GoodTilCanceled time in force. Each rejection has its
own message inMessages.Brokerages. The two rules that need both legs at once — same symbol, one of each
order type — stay in the plugin, which sees the whole group after it is buffered. - Placement (
AlpacaBrokerage.PlaceOrder). The legs are buffered withGroupOrderCacheManager, then
orders.CreateAlpacaOneCancelsTheOtherOrder(_symbolMapper)builds the SDK's typedOneCancelsOtherOrder:
the Lean limit leg becomes the parent order (type=limit, take profit price intake_profit.limit_price)
and the Lean stop leg becomes the nestedstop_loss.stop_price. - Ids. Both Lean legs get their own Alpaca order id before the stream lock releases: the parent id from
the POST response, the child id fromresponse.Legs, with a singleGetOrderAsyncfallback when the
response carries no child (the child id in the POST response is commonly observed but not clearly
documented). Without this the external-order path would adopt the server-created leg as a foreign order. - Stream. Per-leg events route by broker id with no change.
TradeEvent.Heldis now handled next to
New/PendingNew: the passive leg is reported asheldand never gets aNewevent first, so without
registering it there its latercanceledevent was dropped. And when a leg fills while its sibling is
alreadyFilled, aBrokerageMessageEventwarning is emitted — Alpaca documents that "in extremely
volatile and fast market conditions, both orders may fill before the cancellation occurs". - Cancel. No new code. DELETE on any leg cancels the group server-side.
Two things Alpaca's own docs leave open, to confirm on paper trading: whether the API rejects an OCO with no
open position (its OCO is described as exit-only), and whether IOC/FOK are accepted. The Alpaca ADR
(Documentation/Lean.Brokerages.Alpaca/ADR/0001-one-cancels-the-other-orders.md) holds the full mapping,
the answers from Alpaca support, and the known restart-reconciliation gap.
Live mapping: InteractiveBrokers
Repo: Lean.Brokerages.InteractiveBrokers, branch feature-oco-orders, draft PR #244. IB has no single
"OCO request". Every leg is a normal order, and two fields tie the legs together: OcaGroup, a text label
shared by every leg, and OcaType, what happens when one leg executes. What shipped:
- Placement. Each leg is its own IB order: its own id, its own plain contract (not a BAG) and its own
placeOrdercall.IBPlaceOrderloops over the buffered legs and places them one by one. The label is
built from the Lean group id,lean-oco-{groupOrderManagerId}. There is noParentId: that is a
bracket/OTO tool, not an OCO tool. - Restart.
GetOpenOrdersputs orders that share anOcaGrouplabel back into one Lean group with a
rebuiltGroupOrderManager. A label with only one order left is not treated as a group. - Update. One leg updates on its own. The combo path waits for a sibling update to arrive too; an OCO
leg does not. - Combo-only paths, narrowed.
EmitOrderFillonly waits for every leg when the group is a real combo —
in an OCO group the losing legs never fill, so the buffer would always run into its timeout. The shared
combo contract cache and the group quantity/direction rule are narrowed the same way: an OCO leg keeps its
own quantity and direction, there is no ratio math.
Three things came out differently from the plan in this document, all confirmed against a live IB Gateway:
| Planned here | What shipped | Why |
|---|---|---|
OcaType = 2, reduce the remaining legs |
OcaType = 1, cancel the remaining legs |
Type 1 also routes only one leg at a time, which is what blocks an overfill |
Transmit = false on every leg but the last |
Transmit = true on every leg |
A held order is a TWS window feature: a grey row a person clicks. Gateway has no window, so the held legs never activated and only the last leg reached the market |
Losing legs report Canceled |
a losing leg can report Invalid |
IB answers a leg sent after the group already filled with error 201, "OCA group is already filled", status Inactive, which Lean maps to Invalid |
The OcaType difference matters and is not settled. The engine reduces the siblings by the executed
quantity (see "Partial fills"); IB type 1 cancels them instead. So a partial fill on one leg leaves the rest
of the position protected in a backtest and unprotected live. Either the engine rule or the IB mapping has
to move before this ships.
The other two are lower risk. The Transmit finding is a plain correction — the official sample only shows
that technique in VB, and it does not work headless. The Invalid reporting only appears when a leg can fill
instantly, so a leg placed after it is refused; two resting exit legs, the normal case, all reach the market
first and the winner cancels the rest properly. Still worth deciding whether error 201 should map to
Canceled for a leg of an OCO group.
The IB-side ADR (in that repo, Documentation/ADR/0001-one-cancels-the-other-orders.md) holds the full
mapping, the real Gateway log of a partial fill and the per-file list of changes.
Unit tests
OrderJsonConverterTests: a plainLimitOrderwith an OCO group survives two consecutive round trips
(the second catches theDeserializeGroupOrderManagerdrop combined withDefaultValueHandling.Ignore);
old JSON without the field loads asCombo.OrderTests:Order.CreateOrderattaches the manager to a plain leg before the Id is set, so the leg
registers itself intoOrderIds.AlgorithmTradingTests: the two tickets come back limit-first and share one manager with
GroupExecutionType.OneCancelsTheOther; zero quantity returns one invalid ticket with
OrderResponseErrorCode.ZeroQuantity; the method-leveltagandorderPropertiesland on both legs;
Liquidatecancels every leg of an open group and does not throw.BacktestingBrokerageTests:LegFillCancelsSiblingInSameEventBatch,StopLegWinsTieOverLimitLeg,
CancelingOneLegCancelsWholeGroup,CancelOrderLeavesAlreadyClosedLegUntouched,
TimeInForceExpiryOnAnyLegCancelsWholeGroup,PartialFillReducesSiblingsAndGroupStaysPending,
GroupIsProcessedOnlyOnceExactlyPerScanDespiteTwoPendingEntries. These run the same code paths OTO and the
bracket will run, so this suite is the safety net for the next two PRs too.BrokerageTransactionHandlerTests: legs buffer until the group is complete; a submit-time failure
invalidates every leg; the update path validates buying power for OCO but not for combo.AlpacaBrokerageModelTests: a valid group passesCanSubmitOrder, and each of the five per-leg rules
rejects on its own.- Buying power tests: a cash account holding exactly 1 BTC can place the sell take profit + sell stop loss
pair throughOneCancelsTheOtherOrderitself, so the whole submit path runs and the sibling exclusion is
what makes it pass; the group check uses the most expensive leg; same-symbol legs skip the position-group
path.
Regression algorithms
Three self-verifying algorithms on SPY hourly, January 2019 (the data window the repo already uses for
order-type regressions). The first two ship as C# + Python twins; the third is C# only, because it needs a
custom fill model.
OneCancelsTheOtherOrderRegressionAlgorithm — the winner path: buy 100 SPY, then place the exit pair with the
take profit +1% (reached by the January rally) and the stop -30% (never reachable). Asserts the limit leg
fills, the stop leg ends Canceled, and the algorithm ends flat.
OneCancelsTheOtherOrderCancelRegressionAlgorithm — the cancel path: place the group with both prices 30%
away, cancel one ticket a few days later, assert both legs end Canceled and the original market order
fill is untouched.
OneCancelsTheOtherOrderPartialFillRegressionAlgorithm — the reduce path (C# only). A custom fill model
executes the stop leg once, partially, and then goes quiet, so the limit leg finishes the group. Neither leg
looks at the market price, so the case runs the same way every time. The stop executes 30 of the 100 shares,
so the limit leg must shrink to 70 and sell only 70 — the assertion is on the leg's quantity, not just the
totals, because a group that simply stopped early would also end flat.
Two things that algorithm does deliberately: it throws from OnOrderEvent rather than from the fill model,
because TryFillLeg catches fill-model exceptions into Algorithm.Error where they are not a test failure;
and both fill overrides check IsExchangeOpen, so a bar outside market hours cannot let the limit leg execute
on its own.
None of the three round their prices. Lean rounds order prices to the brokerage's precision before sending
them.
Open questions
- Max legs and API width. The engine has no leg-count guard; only the public method signature limits us to
2. Keep it narrow or open it up? The blocker to going wide is margin, not the group plumbing. Written up in
0002-open-question-group-scope.md, waiting on review. - Different symbols in one group. The engine allows it and has its own buying-power branch for it; the
Alpaca model restricts to one symbol. Is per-leg checking the right rule for the multi-symbol case? - Trigger leg marker for OTO/bracket.
GroupOrderManager.TriggerOrderId(plain legs, no new order types)
instead of ADR 0001'sBracketMarket/BracketLimit. Trade-off to confirm in ADR 0003: plain legs reuse
every existing code path, but new types make the entry leg visible inOrderTypedispatches and reports. - Alpaca semantics. Alpaca's OCO reads as exit-only. Verify on paper trading that a fresh OCO with no
position is rejected, and mirror that rejection message in the model. UpdateOrderon a live OCO leg. Lean now allows per-leg updates. Whether Alpaca's PATCH endpoint updates
one leg of a live group without disturbing the sibling is unverified.
Decided since the proposal:
- Partial fills reduce the siblings instead of canceling them. The IB mapping does not match this yet, it uses
OcaType = 1and cancels them — still to settle. - The enum is
GroupExecutionType, notComboType. - Canceling a group from the algorithm side stays leg by leg.
LiquidateandCancelOpenOrderssend one
cancel per open order and let the extra ones fail harmlessly, instead of carrying a per-group skip. - No brokerage gate in v1.
SupportsGroupExecutionis not added toIBrokerageModel. The removed shape and
the conditions for bringing it back are recorded under "Live safety: no brokerage gate in v1".
Known gaps carried from the code review
Full detail in 0002-implementation-review.md. Still open at the time of
writing:
- No brokerage gate. A plugin that does not understand order groups places the legs unlinked and both can
fill. Alpaca and InteractiveBrokers both map the group themselves; nothing guards any other brokerage. A
deliberate v1 choice — see "Live safety: no brokerage gate in v1". - IB cancels the siblings where the engine reduces them (
OcaType = 1). A partial fill behaves
differently live and in a backtest — see "Live mapping: InteractiveBrokers". - Cash accounts over-reserve against a resting OCO pair. The sibling exclusion only helps when the order
being checked is itself a leg of that group. Any other order still counts both legs (review §3.2). - Two of the five pending-set touch points are missing (review §4.6).
- The reduce-to-zero branch of
ReduceOpenSiblingshas no test. Only reachable when a per-leg update
gives the legs different sizes. The whole per-leg update path has no brokerage-level coverage (review §6). - Code motion the reuse table promises but does not have yet: the emit/remove tail is written twice with
two different rules, the group buying-power block is duplicated,SubmitGroupOrderrepeats half of
SubmitComboOrder, and the leg-type allowlist plus the stops-first order are inline instead of named steps
(review §2.1-2.3, §4.2, §4.4, §4.5). The stops-first key also sortsStopLimitandTrailingStopinto the
limit bucket, which matters as soon as the allowlist widens. Not part of this feature:Logging/CompositeLogHandler.csandTests/Brokerages/BrokerageTests.cs.
Closed 2026-08-20 — both files are back to master, so the pull request no longer touches logging.
Description
Adds a one-cancels-the-other (OCO) order group.
QCAlgorithm.OneCancelsTheOtherOrderplaces a take profit limit leg and a stop loss stop market leg together, both for the same quantity. Both legs rest in the market; the first one to execute ends the group.C#:
Python:
The group can never trade more than the quantity it was given. When a leg executes, that quantity comes off every other open leg, and a leg left with nothing is canceled in the same order event batch. A full fill is just the case where the others reach zero. This also covers a partial fill: the stop selling 30 of 100 leaves the limit leg at 70, so the position keeps its target and the group never sells 130.
Main changes:
GroupExecutionTypeenum (Combo,OneCancelsTheOther) andGroupOrderManager.ExecutionType, serialized asexecutionType. Groups serialized before this keep loading asCombo.Order.CreateOrderandOrderJsonConverterattach the group manager to a plainLimit/StopMarketleg before its Id is set, so a leg restored from JSON stays in its group. Only combo order types carried the manager through those paths before.QCAlgorithm.SubmitGroupOrderis the shared submitter for group types that are not the ratio combo: it builds the legs' requests, runs every pre-order check before submitting any leg, and submits in list order. The conditional (OTO) and bracket orders add their own thin wrapper over it.BacktestingBrokeragesimulates the group so backtesting and paper trading need no brokerage support. Legs are evaluated in a fixed order — stop legs first, then by id — and the first leg to execute any quantity ends the pass and reduces its siblings.CashBuyingPowerModelstops counting a sibling's quantity as reserved, andGetProjectedHoldingsandShortablecount one leg per group.HandleUpdateOrderRequestnow validates buying power when a leg of a non-combo group is updated. Combo legs keep the old skip.AlpacaBrokerageModel.CanSubmitOrderchecks Alpaca's own rules per leg: 2 legs, US equity, limit or stop market, both legs on the same side, day or good til canceled.Related PR(s)
Both are drafts that depend on this one:
Related Issue
#8253
Motivation and Context
First step of the bracket order plan: OCO now, conditional (OTO) next, bracket on top of both. A user can protect a position with a take profit and a stop loss pair without canceling the other one by hand, and without the risk of both filling.
Requires Documentation Change
Yes. The new
OneCancelsTheOtherOrderAPI needs a page, and the brokerage pages need to say which brokerages accept the group once the two plugin pull requests land.How Has This Been Tested?
Regression algorithms on SPY hourly, January 2019:
OneCancelsTheOtherOrderRegressionAlgorithm(C# and Python) — the take profit leg fills, the stop leg endsCanceledin the same batch, and the algorithm ends flat.OneCancelsTheOtherOrderCancelRegressionAlgorithm(C# and Python) — canceling one leg cancels both, and the original market order fill is untouched.OneCancelsTheOtherOrderPartialFillRegressionAlgorithm(C# only, it needs a custom fill model) — the stop leg executes 30 of 100 and the limit leg has to shrink to 70. It asserts the leg quantity, not just the total, because a group that stopped early would also end flat.OneCancelsTheOtherOrderDemoAlgorithm— a manual aid for running the group against a live or paper brokerage. It does not implementIRegressionAlgorithmDefinition.Unit tests:
BacktestingBrokerageTests:LegFillCancelsSiblingInSameEventBatch,StopLegWinsTieOverLimitLeg,CancelingOneLegCancelsWholeGroup,CancelOrderLeavesAlreadyClosedLegUntouched,TimeInForceExpiryOnAnyLegCancelsWholeGroup,PartialFillReducesSiblingsAndGroupStaysPending,GroupIsProcessedOnlyOnceExactlyPerScanDespiteTwoPendingEntries.AlgorithmTradingTests: the two tickets come back limit first and share one manager; a zero quantity returns one invalid ticket; the tag and order properties reach both legs;Liquidatecancels every leg of an open group.BrokerageTransactionHandlerTests: the legs buffer until the group is complete, a submit time failure invalidates every leg, and the update path validates buying power for OCO but not for combo.SecurityPortfolioManagerTestsandCashBuyingPowerModelTests: the most expensive leg rule, and a cash account holding exactly 1 BTC can place the sell pair throughOneCancelsTheOtherOrder.OrderJsonConverterTestsandOrderTests: a plain leg keeps its group over two round trips, and old JSON without the field loads asCombo.AlpacaBrokerageModelTests: a valid group passes, and each of the five per-leg rules rejects on its own.Types of changes
Checklist:
bug-<issue#>-<description>orfeature-<issue#>-<description>